agentmux_srv\backend\rpc/
engine.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! RPC engine: handles incoming RPC requests, dispatches to handlers,
5//! and manages request/response lifecycle with timeouts and streaming.
6//! Port of Go's pkg/wshutil/wshrpc.go (WshRpc struct + handler dispatch).
7
8
9use std::collections::HashMap;
10use std::future::Future;
11use std::pin::Pin;
12use std::sync::atomic::{AtomicBool, Ordering};
13use std::sync::{Arc, Mutex};
14
15use tokio::sync::mpsc;
16use uuid::Uuid;
17
18use super::super::rpc_types::{RpcContext, RpcMessage, RpcOpts, COMMAND_EVENT_RECV};
19
20// ---- Constants (match Go) ----
21
22pub const DEFAULT_TIMEOUT_MS: i64 = 5000;
23const RESP_CH_SIZE: usize = 32;
24
25// ---- Handler types ----
26
27/// Result type for RPC handler responses.
28pub type HandlerResult = Result<Option<serde_json::Value>, String>;
29
30/// A boxed async handler function.
31/// Takes the command data and returns either:
32/// - Ok(Some(value)) for a single response
33/// - Ok(None) for no response
34/// - Err(msg) for an error response
35pub type CommandHandler = Box<
36    dyn Fn(serde_json::Value, RpcContext) -> Pin<Box<dyn Future<Output = HandlerResult> + Send>>
37        + Send
38        + Sync,
39>;
40
41/// A streaming handler that returns a channel of responses.
42pub type StreamHandler = Box<
43    dyn Fn(
44            serde_json::Value,
45            RpcContext,
46        )
47            -> Pin<Box<dyn Future<Output = Result<mpsc::Receiver<HandlerResult>, String>> + Send>>
48        + Send
49        + Sync,
50>;
51
52enum Handler {
53    Call(CommandHandler),
54    #[allow(dead_code)]
55    Stream(StreamHandler),
56}
57
58// ---- RPC Response Handler ----
59
60/// Allows an RPC handler to send responses back to the caller.
61/// Matches Go's `RpcResponseHandler`.
62pub struct RpcResponseHandler {
63    engine: Arc<WshRpcEngine>,
64    req_id: String,
65    #[allow(dead_code)]
66    source: String,
67    canceled: AtomicBool,
68    done: AtomicBool,
69}
70
71impl RpcResponseHandler {
72    /// Send a single response (or streaming chunk).
73    /// Set `done` to true for the final response.
74    pub fn send_response(&self, data: Option<serde_json::Value>, done: bool) {
75        if self.done.load(Ordering::Relaxed) {
76            return;
77        }
78        let msg = RpcMessage {
79            resid: self.req_id.clone(),
80            data,
81            cont: !done,
82            ..Default::default()
83        };
84        if done {
85            self.done.store(true, Ordering::Relaxed);
86        }
87        self.engine.send_output(msg);
88    }
89
90    /// Send an error response.
91    pub fn send_error(&self, err: &str) {
92        if self.done.load(Ordering::Relaxed) {
93            return;
94        }
95        self.done.store(true, Ordering::Relaxed);
96        let msg = RpcMessage {
97            resid: self.req_id.clone(),
98            error: err.to_string(),
99            ..Default::default()
100        };
101        self.engine.send_output(msg);
102    }
103
104    /// Check if the request has been canceled.
105    #[allow(dead_code)]
106    pub fn is_canceled(&self) -> bool {
107        self.canceled.load(Ordering::Relaxed)
108    }
109
110    /// Get the source route ID of the request.
111    #[allow(dead_code)]
112    pub fn get_source(&self) -> &str {
113        &self.source
114    }
115
116    /// Mark this handler as canceled.
117    fn cancel(&self) {
118        self.canceled.store(true, Ordering::Relaxed);
119    }
120
121    /// Finalize: send empty done response if not already done.
122    fn finalize(&self) {
123        if self.done.load(Ordering::Relaxed) {
124            return;
125        }
126        self.send_response(None, true);
127    }
128}
129
130// ---- RPC Request Handler (client-side) ----
131
132/// Tracks an outgoing request and collects responses.
133/// Matches Go's `RpcRequestHandler`.
134#[allow(dead_code)]
135pub struct RpcRequestHandler {
136    req_id: String,
137    resp_rx: mpsc::Receiver<RpcMessage>,
138    last_was_cont: bool,
139}
140
141impl RpcRequestHandler {
142    /// Get the next response. Returns None if the stream is done.
143    #[allow(dead_code)]
144    pub async fn next_response(&mut self) -> Option<Result<serde_json::Value, String>> {
145        if !self.last_was_cont && self.req_id.is_empty() {
146            return None;
147        }
148        match self.resp_rx.recv().await {
149            Some(msg) => {
150                self.last_was_cont = msg.cont;
151                if !msg.error.is_empty() {
152                    Some(Err(msg.error))
153                } else {
154                    Some(Ok(msg.data.unwrap_or(serde_json::Value::Null)))
155                }
156            }
157            None => None,
158        }
159    }
160
161    /// Check if the response stream is complete.
162    #[allow(dead_code)]
163    pub fn is_done(&self) -> bool {
164        !self.last_was_cont
165    }
166
167    /// Get the request ID.
168    #[allow(dead_code)]
169    pub fn req_id(&self) -> &str {
170        &self.req_id
171    }
172}
173
174// ---- RPC Engine ----
175
176struct EngineInner {
177    handlers: HashMap<String, Handler>,
178    pending_responses: HashMap<String, mpsc::Sender<RpcMessage>>,
179    active_handlers: HashMap<String, Arc<RpcResponseHandler>>,
180    #[allow(dead_code)]
181    auth_token: String,
182    rpc_context: Option<RpcContext>,
183}
184
185/// Core RPC engine: handles incoming RPC requests, dispatches to registered
186/// command handlers, and manages request/response lifecycle.
187///
188/// Port of Go's `WshRpc` from pkg/wshutil/wshrpc.go.
189pub struct WshRpcEngine {
190    inner: Mutex<EngineInner>,
191    output_tx: mpsc::UnboundedSender<RpcMessage>,
192}
193
194impl WshRpcEngine {
195    /// Lock `inner`, recovering from mutex poison instead of propagating
196    /// the panic. A poisoned mutex means a previous handler panicked while
197    /// holding the lock — the data is still consistent for our purposes
198    /// (inserts/removes on HashMaps), so recovery is safe here.
199    fn lock_inner(&self) -> std::sync::MutexGuard<'_, EngineInner> {
200        self.inner.lock().unwrap_or_else(|e| e.into_inner())
201    }
202
203    /// Create a new RPC engine.
204    /// Returns the engine and a receiver for outgoing messages.
205    pub fn new() -> (Arc<Self>, mpsc::UnboundedReceiver<RpcMessage>) {
206        let (output_tx, output_rx) = mpsc::unbounded_channel();
207        let engine = Arc::new(Self {
208            inner: Mutex::new(EngineInner {
209                handlers: HashMap::new(),
210                pending_responses: HashMap::new(),
211                active_handlers: HashMap::new(),
212                auth_token: String::new(),
213                rpc_context: None,
214            }),
215            output_tx,
216        });
217        (engine, output_rx)
218    }
219
220    /// Register a call handler (single request → single response).
221    pub fn register_handler(&self, command: &str, handler: CommandHandler) {
222        let mut inner = self.lock_inner();
223        inner
224            .handlers
225            .insert(command.to_string(), Handler::Call(handler));
226    }
227
228    /// Register a streaming handler (single request → stream of responses).
229    #[allow(dead_code)]
230    pub fn register_stream_handler(&self, command: &str, handler: StreamHandler) {
231        let mut inner = self.lock_inner();
232        inner
233            .handlers
234            .insert(command.to_string(), Handler::Stream(handler));
235    }
236
237    /// Set the authentication token.
238    #[allow(dead_code)]
239    pub fn set_auth_token(&self, token: &str) {
240        let mut inner = self.lock_inner();
241        inner.auth_token = token.to_string();
242    }
243
244    /// Get the authentication token.
245    #[allow(dead_code)]
246    pub fn get_auth_token(&self) -> String {
247        let inner = self.lock_inner();
248        inner.auth_token.clone()
249    }
250
251    /// Set the RPC context.
252    #[allow(dead_code)]
253    pub fn set_rpc_context(&self, ctx: RpcContext) {
254        let mut inner = self.lock_inner();
255        inner.rpc_context = Some(ctx);
256    }
257
258    /// Process an incoming message (from the transport layer).
259    pub fn handle_message(self: &Arc<Self>, msg: RpcMessage) {
260        // Cancel handling
261        if msg.cancel {
262            if !msg.reqid.is_empty() {
263                self.handle_cancel_request(&msg.reqid);
264            }
265            return;
266        }
267
268        // Event handling (special: no response)
269        if msg.command == COMMAND_EVENT_RECV {
270            // Events are handled by the event listener, not via RPC handlers
271            return;
272        }
273
274        // New command (request)
275        if !msg.command.is_empty() {
276            let engine = self.clone();
277            tokio::spawn(async move {
278                engine.handle_request(msg).await;
279            });
280            return;
281        }
282
283        // Response (has resid)
284        if !msg.resid.is_empty() {
285            self.handle_response(msg);
286        }
287    }
288
289    /// Send an RPC command and wait for a single response.
290    #[allow(dead_code)]
291    pub async fn send_command(
292        self: &Arc<Self>,
293        command: &str,
294        data: serde_json::Value,
295        opts: &RpcOpts,
296    ) -> Result<serde_json::Value, String> {
297        let mut handler = self.send_request(command, data, opts)?;
298        match handler.next_response().await {
299            Some(result) => result,
300            None => Err("no response received".to_string()),
301        }
302    }
303
304    /// Send an RPC command and get a request handler for streaming responses.
305    #[allow(dead_code)]
306    pub fn send_request(
307        self: &Arc<Self>,
308        command: &str,
309        data: serde_json::Value,
310        opts: &RpcOpts,
311    ) -> Result<RpcRequestHandler, String> {
312        let req_id = Uuid::new_v4().to_string();
313        let (resp_tx, resp_rx) = mpsc::channel(RESP_CH_SIZE);
314
315        {
316            let mut inner = self.lock_inner();
317            inner
318                .pending_responses
319                .insert(req_id.clone(), resp_tx);
320        }
321
322        let timeout = if opts.timeout > 0 {
323            opts.timeout
324        } else {
325            DEFAULT_TIMEOUT_MS
326        };
327        let route = if opts.route.is_empty() {
328            String::new()
329        } else {
330            opts.route.clone()
331        };
332
333        let msg = RpcMessage {
334            command: command.to_string(),
335            reqid: req_id.clone(),
336            timeout,
337            route,
338            data: Some(data),
339            authtoken: self.get_auth_token(),
340            ..Default::default()
341        };
342        self.send_output(msg);
343
344        Ok(RpcRequestHandler {
345            req_id,
346            resp_rx,
347            last_was_cont: true, // assume more data initially
348        })
349    }
350
351    /// Send a fire-and-forget command (no response expected).
352    #[allow(dead_code)]
353    pub fn send_command_no_response(
354        &self,
355        command: &str,
356        data: serde_json::Value,
357        route: &str,
358    ) {
359        let msg = RpcMessage {
360            command: command.to_string(),
361            data: Some(data),
362            route: route.to_string(),
363            authtoken: self.get_auth_token(),
364            ..Default::default()
365        };
366        self.send_output(msg);
367    }
368
369    // ---- Internal ----
370
371    fn send_output(&self, msg: RpcMessage) {
372        if self.output_tx.send(msg).is_err() {
373            tracing::warn!("[rpc-engine] output channel closed — message dropped");
374        }
375    }
376
377    async fn handle_request(self: Arc<Self>, msg: RpcMessage) {
378        let request_start = std::time::Instant::now();
379        let timeout_ms = if msg.timeout > 0 {
380            msg.timeout
381        } else {
382            DEFAULT_TIMEOUT_MS
383        };
384
385        let handler = Arc::new(RpcResponseHandler {
386            engine: self.clone(),
387            req_id: msg.reqid.clone(),
388            source: msg.source.clone(),
389            canceled: AtomicBool::new(false),
390            done: AtomicBool::new(false),
391        });
392
393        // Register the active handler
394        if !msg.reqid.is_empty() {
395            let mut inner = self.lock_inner();
396            inner
397                .active_handlers
398                .insert(msg.reqid.clone(), handler.clone());
399        }
400
401        let rpc_context = {
402            let inner = self.lock_inner();
403            inner.rpc_context.clone().unwrap_or_default()
404        };
405
406        let data = msg.data.unwrap_or(serde_json::Value::Null);
407        let command = msg.command.clone();
408
409        // Look up handler
410        let has_call;
411        let has_stream;
412        {
413            let inner = self.lock_inner();
414            match inner.handlers.get(&command) {
415                Some(Handler::Call(_)) => {
416                    has_call = true;
417                    has_stream = false;
418                }
419                Some(Handler::Stream(_)) => {
420                    has_call = false;
421                    has_stream = true;
422                }
423                None => {
424                    has_call = false;
425                    has_stream = false;
426                }
427            }
428        }
429
430        let dispatch_elapsed = request_start.elapsed();
431
432        if !has_call && !has_stream {
433            handler.send_error(&format!("unknown command: {}", command));
434            self.cleanup_handler(&msg.reqid);
435            return;
436        }
437
438        let timeout_dur = std::time::Duration::from_millis(timeout_ms as u64);
439
440        if has_call {
441            // Call handler: single response with timeout.
442            // Create the future while holding the lock, then drop the lock before awaiting.
443            let handler_start = std::time::Instant::now();
444            let fut = {
445                let inner = self.lock_inner();
446                match inner.handlers.get(&command) {
447                    Some(Handler::Call(h)) => h(data.clone(), rpc_context.clone()),
448                    _ => Box::pin(async { Err("handler disappeared".to_string()) }),
449                }
450            };
451            let result = tokio::time::timeout(timeout_dur, fut).await;
452            let handler_elapsed = handler_start.elapsed();
453            let total_elapsed = request_start.elapsed();
454
455            tracing::info!(
456                "[rpc-perf] command={} dispatch={:.2}ms handler={:.2}ms total={:.2}ms",
457                command,
458                dispatch_elapsed.as_secs_f64() * 1000.0,
459                handler_elapsed.as_secs_f64() * 1000.0,
460                total_elapsed.as_secs_f64() * 1000.0,
461            );
462
463            match result {
464                Ok(Ok(resp_data)) => handler.send_response(resp_data, true),
465                Ok(Err(err)) => handler.send_error(&err),
466                Err(_) => handler.send_error(&format!("EC-TIME: timeout ({}ms)", timeout_ms)),
467            }
468        } else {
469            // Stream handler: same pattern — build future under lock, await outside.
470            let fut = {
471                let inner = self.lock_inner();
472                match inner.handlers.get(&command) {
473                    Some(Handler::Stream(h)) => h(data.clone(), rpc_context.clone()),
474                    _ => Box::pin(async { Err("handler disappeared".to_string()) }),
475                }
476            };
477            let stream_result = tokio::time::timeout(timeout_dur, fut).await;
478
479            match stream_result {
480                Ok(Ok(mut rx)) => {
481                    // Read streaming responses
482                    loop {
483                        match tokio::time::timeout(timeout_dur, rx.recv()).await {
484                            Ok(Some(Ok(resp_data))) => {
485                                handler.send_response(resp_data, false);
486                            }
487                            Ok(Some(Err(err))) => {
488                                handler.send_error(&err);
489                                break;
490                            }
491                            Ok(None) => {
492                                // Channel closed — stream done
493                                handler.finalize();
494                                break;
495                            }
496                            Err(_) => {
497                                handler.send_error(&format!(
498                                    "EC-TIME: stream timeout ({}ms)",
499                                    timeout_ms
500                                ));
501                                break;
502                            }
503                        }
504                    }
505                }
506                Ok(Err(err)) => handler.send_error(&err),
507                Err(_) => {
508                    handler.send_error(&format!("EC-TIME: timeout ({}ms)", timeout_ms))
509                }
510            }
511        }
512
513        self.cleanup_handler(&msg.reqid);
514    }
515
516    fn handle_response(&self, msg: RpcMessage) {
517        let inner = self.lock_inner();
518        if let Some(tx) = inner.pending_responses.get(&msg.resid) {
519            let is_done = !msg.cont;
520            if tx.try_send(msg.clone()).is_err() {
521                tracing::warn!(resid = %msg.resid, "[rpc-engine] response channel full/closed — reply dropped");
522            }
523            if is_done {
524                drop(inner);
525                let mut inner = self.lock_inner();
526                inner.pending_responses.remove(&msg.resid);
527            }
528        }
529    }
530
531    fn handle_cancel_request(&self, req_id: &str) {
532        let inner = self.lock_inner();
533        if let Some(handler) = inner.active_handlers.get(req_id) {
534            handler.cancel();
535        }
536    }
537
538    fn cleanup_handler(&self, req_id: &str) {
539        if req_id.is_empty() {
540            return;
541        }
542        let mut inner = self.lock_inner();
543        inner.active_handlers.remove(req_id);
544    }
545}
546
547// ====================================================================
548// Tests
549// ====================================================================
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[tokio::test]
556    async fn test_register_and_call_handler() {
557        let (engine, mut output_rx) = WshRpcEngine::new();
558
559        engine.register_handler(
560            "echo",
561            Box::new(|data, _ctx| {
562                Box::pin(async move { Ok(Some(data)) })
563            }),
564        );
565
566        let msg = RpcMessage {
567            command: "echo".to_string(),
568            reqid: "req-1".to_string(),
569            data: Some(serde_json::json!({"hello": "world"})),
570            ..Default::default()
571        };
572        engine.handle_message(msg);
573
574        // Collect the response
575        let resp = tokio::time::timeout(
576            std::time::Duration::from_secs(1),
577            output_rx.recv(),
578        )
579        .await
580        .unwrap()
581        .unwrap();
582
583        assert_eq!(resp.resid, "req-1");
584        assert!(!resp.cont);
585        assert_eq!(resp.data, Some(serde_json::json!({"hello": "world"})));
586    }
587
588    #[tokio::test]
589    async fn test_unknown_command_returns_error() {
590        let (engine, mut output_rx) = WshRpcEngine::new();
591
592        let msg = RpcMessage {
593            command: "nonexistent".to_string(),
594            reqid: "req-2".to_string(),
595            ..Default::default()
596        };
597        engine.handle_message(msg);
598
599        let resp = tokio::time::timeout(
600            std::time::Duration::from_secs(1),
601            output_rx.recv(),
602        )
603        .await
604        .unwrap()
605        .unwrap();
606
607        assert_eq!(resp.resid, "req-2");
608        assert!(resp.error.contains("unknown command"));
609    }
610
611    #[tokio::test]
612    async fn test_handler_error_returns_error_response() {
613        let (engine, mut output_rx) = WshRpcEngine::new();
614
615        engine.register_handler(
616            "failme",
617            Box::new(|_data, _ctx| {
618                Box::pin(async move { Err("something went wrong".to_string()) })
619            }),
620        );
621
622        let msg = RpcMessage {
623            command: "failme".to_string(),
624            reqid: "req-3".to_string(),
625            ..Default::default()
626        };
627        engine.handle_message(msg);
628
629        let resp = tokio::time::timeout(
630            std::time::Duration::from_secs(1),
631            output_rx.recv(),
632        )
633        .await
634        .unwrap()
635        .unwrap();
636
637        assert_eq!(resp.error, "something went wrong");
638    }
639
640    #[tokio::test]
641    async fn test_send_command_roundtrip() {
642        let (engine, mut output_rx) = WshRpcEngine::new();
643
644        // Spawn a "server" that echoes responses
645        let engine_clone = engine.clone();
646        tokio::spawn(async move {
647            if let Some(msg) = output_rx.recv().await {
648                // This is the outgoing request — echo it back as a response
649                let resp = RpcMessage {
650                    resid: msg.reqid.clone(),
651                    data: msg.data.clone(),
652                    ..Default::default()
653                };
654                engine_clone.handle_message(resp);
655            }
656        });
657
658        let opts = RpcOpts {
659            timeout: 1000,
660            ..Default::default()
661        };
662        let result = engine
663            .send_command("test", serde_json::json!(42), &opts)
664            .await;
665
666        assert!(result.is_ok());
667        assert_eq!(result.unwrap(), serde_json::json!(42));
668    }
669
670    #[tokio::test]
671    async fn test_stream_handler() {
672        let (engine, mut output_rx) = WshRpcEngine::new();
673
674        engine.register_stream_handler(
675            "counter",
676            Box::new(|_data, _ctx| {
677                Box::pin(async move {
678                    let (tx, rx) = mpsc::channel(8);
679                    tokio::spawn(async move {
680                        for i in 0..3 {
681                            let _ = tx.send(Ok(Some(serde_json::json!(i)))).await;
682                        }
683                        // Channel drops → stream done
684                    });
685                    Ok(rx)
686                })
687            }),
688        );
689
690        let msg = RpcMessage {
691            command: "counter".to_string(),
692            reqid: "req-stream".to_string(),
693            ..Default::default()
694        };
695        engine.handle_message(msg);
696
697        // Collect streaming responses
698        let mut responses = Vec::new();
699        for _ in 0..4 {
700            // 3 data + 1 final empty
701            match tokio::time::timeout(
702                std::time::Duration::from_secs(2),
703                output_rx.recv(),
704            )
705            .await
706            {
707                Ok(Some(resp)) => responses.push(resp),
708                _ => break,
709            }
710        }
711
712        // Should have 3 streaming chunks + 1 final
713        assert!(responses.len() >= 3);
714        // First 3 have cont=true
715        for resp in &responses[..3] {
716            assert!(resp.cont);
717        }
718        // Last one has cont=false (finalize)
719        if responses.len() == 4 {
720            assert!(!responses[3].cont);
721        }
722    }
723
724    #[tokio::test]
725    async fn test_cancel_request() {
726        let (engine, mut output_rx) = WshRpcEngine::new();
727
728        let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
729        engine.register_handler(
730            "slow",
731            Box::new(move |_data, _ctx| {
732                Box::pin(async move {
733                    // Signal that we started
734                    // (can't move started_tx into closure that's called multiple times)
735                    tokio::time::sleep(std::time::Duration::from_secs(10)).await;
736                    Ok(Some(serde_json::json!("done")))
737                })
738            }),
739        );
740
741        // Send command
742        let msg = RpcMessage {
743            command: "slow".to_string(),
744            reqid: "req-cancel".to_string(),
745            timeout: 10000,
746            ..Default::default()
747        };
748        engine.handle_message(msg);
749
750        // Small delay then send cancel
751        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
752        let cancel_msg = RpcMessage {
753            cancel: true,
754            reqid: "req-cancel".to_string(),
755            ..Default::default()
756        };
757        engine.handle_message(cancel_msg);
758
759        // The handler will still time out or complete, but the cancel flag should be set
760        // Just verify we get a response eventually (timeout response)
761        let resp = tokio::time::timeout(
762            std::time::Duration::from_secs(12),
763            output_rx.recv(),
764        )
765        .await;
766        assert!(resp.is_ok());
767        // Clean up to avoid unused variable warning
768        drop(started_tx);
769        drop(started_rx);
770    }
771
772    #[tokio::test]
773    async fn test_send_command_no_response() {
774        let (engine, mut output_rx) = WshRpcEngine::new();
775
776        engine.send_command_no_response("notify", serde_json::json!({"msg": "hi"}), "");
777
778        let msg = tokio::time::timeout(
779            std::time::Duration::from_millis(100),
780            output_rx.recv(),
781        )
782        .await
783        .unwrap()
784        .unwrap();
785
786        assert_eq!(msg.command, "notify");
787        assert!(msg.reqid.is_empty());
788    }
789
790    #[tokio::test]
791    async fn test_auth_token() {
792        let (engine, _output_rx) = WshRpcEngine::new();
793        assert!(engine.get_auth_token().is_empty());
794
795        engine.set_auth_token("my-secret-token");
796        assert_eq!(engine.get_auth_token(), "my-secret-token");
797    }
798
799    #[tokio::test]
800    async fn test_rpc_context() {
801        let (engine, _output_rx) = WshRpcEngine::new();
802
803        let ctx = RpcContext {
804            client_type: "connserver".to_string(),
805            blockid: "blk-1".to_string(),
806            ..Default::default()
807        };
808        engine.set_rpc_context(ctx);
809
810        // The context is passed to handlers
811        engine.register_handler(
812            "checkctx",
813            Box::new(|_data, ctx| {
814                Box::pin(async move {
815                    Ok(Some(serde_json::json!({
816                        "ctype": ctx.client_type,
817                        "blockid": ctx.blockid,
818                    })))
819                })
820            }),
821        );
822
823        let msg = RpcMessage {
824            command: "checkctx".to_string(),
825            reqid: "req-ctx".to_string(),
826            ..Default::default()
827        };
828        engine.handle_message(msg);
829
830        // Output will contain the context
831        // (tested indirectly through handler dispatch)
832    }
833}